mcp: Allow registration of custom JSON-RPC methods#956
Conversation
… with type safety
…server test files
maciej-kisiel
left a comment
There was a problem hiding this comment.
Adding @jba who was responding in similar discussions regarding custom notifications. He has deeper understanding than me.
|
@guglielmo-san Hi, thanks for handling this issue! |
|
Hi @guglielmo-san, thanks a lot for putting this together — really appreciate the work here. 🙏 Wanted to gently check in on where this stands. Custom JSON-RPC method support is starting to become a blocker for a growing set of MCP extensions: the Skills-over-MCP WG, for example, is speccing a It looks like there are a few open review items from @yarolegovich and @maciej-kisiel (guarding against overriding standard methods, the Totally understand if you've been busy — is there anything we can do to help move this along? I'm happy to pitch in on the merge conflicts or any of the outstanding review feedback if that would lighten the load. Just let me know what would be most useful. (cc @jba in case there's a preferred direction on the custom-method API the WG should design around.) |
This is my main point of confusion. Why is anyone but the maintainers of this repo building a Go SDK? If you did want to build your own SDK, why wouldn't you fork this repo? If you're building an upcoming feature, why wouldn't that be a PR for this repo? If the feature is still unaccepted or experimental, you could provide a PR gated by a build tag. What am I missing? |
… validation to prevent shadowing standard MCP methods
…xtensions_framework # Conflicts: # mcp/client.go # mcp/mcp_test.go # mcp/server.go # mcp/streamable.go
sambhav
left a comment
There was a problem hiding this comment.
Review: Extension Framework Ergonomics
Great foundation — ParamsBase/ResultBase, shadowing validation, middleware integration, and transport-layer validation are all solid. A few issues and a design suggestion below.
Bug: Missing _meta injection for SEP-2575
CallCustomMethod does not call injectRequestMeta, but every standard client method (CallTool, ListTools, etc.) does when cs.usesNewProtocol() is true. On >= 2026-07-28 sessions over streamable HTTP, the server rejects the request because _meta.protocolVersion is missing:
// streamable.go ~L1486
if metaVersion == "" {
writeJSONRPCError(w, http.StatusBadRequest, ...)
}Custom methods currently only work on legacy protocol sessions or in-memory transports (which skip HTTP validation).
Design: Target the tool-writing experience
Today, writing a tool feels native:
// Define types
type SayHiParams struct {
Name string `json:"name"`
}
// Register — one line, types inferred
mcp.AddTool(server, &mcp.Tool{Name: "greet"}, SayHi)
// Call — first-class method on session, no generics visible
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "greet", Arguments: map[string]any{"name": "user"}})Compare with the current custom method experience:
// Server — generics visible
mcp.AddReceivingCustomMethod(server, "latin/translate", handler)
// Client — must register separately, generics repeated
mcp.AddSendingCustomMethod[*TranslateParams, *TranslateResult](client, "latin/translate")
// Call — generics repeated AGAIN, method name string repeated AGAIN
result, err := mcp.CallCustomMethod[*TranslateParams, *TranslateResult](
ctx, cs, "latin/translate", &TranslateParams{Text: "hello"})Method name repeated 3×, generic type params repeated 3×, client requires manual registration. Not close to the tool experience.
Proposed: CustomMethod struct + global registry
The idea is: the extension author does the generic plumbing once, so the extension consumer never sees generics and gets a tool-like experience.
What the SDK would add:
// CustomMethod captures method name + types in one place.
// Provides RegisterServer(), RegisterClient(), Call() — no generics at call sites.
type CustomMethod[P paramsPtr[PT], R Result, PT any] struct { ... }
func NewCustomMethod[P paramsPtr[PT], R Result, PT any](name string) *CustomMethod[P, R, PT] { ... }
// Global registry — extensions register in init(), NewServer/NewClient apply them.
type Extension struct {
Server func(*Server) error // optional — applied by NewServer
Client func(*Client) error // optional — applied by NewClient
}
func RegisterExtension(ext Extension) { ... }Extension author (writes generic plumbing once):
package latin
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type TranslateParams struct {
mcp.ParamsBase
Text string `json:"text"`
}
type TranslateResult struct {
mcp.ResultBase
Latin string `json:"latin"`
}
// One declaration — name + types captured once
var Method = mcp.NewCustomMethod[*TranslateParams, *TranslateResult]("latin/translate")
func init() {
// Auto-register so consumers can just import
mcp.RegisterExtension(mcp.Extension{
Server: func(s *mcp.Server) error {
return Method.RegisterServer(s, DefaultHandler)
},
Client: func(c *mcp.Client) error {
return Method.RegisterClient(c)
},
})
}
// Domain-specific sugar — the one hand-written helper
func Translate(ctx context.Context, cs *mcp.ClientSession, text string) (*TranslateResult, error) {
return Method.Call(ctx, cs, &TranslateParams{Text: text})
}
// Reference implementation
func DefaultHandler(ctx context.Context, ss *mcp.ServerSession, params *TranslateParams) (*TranslateResult, error) {
return &TranslateResult{Latin: translate(params.Text)}, nil
}Extension consumer (tool-like experience):
import "github.com/acme/mcp-latin" // init() handles all registration
// Server — extension auto-registered via init(), or override with custom handler:
server := mcp.NewServer(impl, nil)
// latin/translate already registered with DefaultHandler!
// Optional: latin.Method.RegisterServer(server, myCustomHandler) to override
// Client — nothing needed, init() handled it
client := mcp.NewClient(impl, nil)
cs, _ := client.Connect(ctx, transport, nil)
// Call — no generics, no method name strings, feels like cs.CallTool()
result, err := latin.Translate(ctx, cs, "hello")
fmt.Println(result.Latin) // "salve"Both global (init()) and server-specific (Method.RegisterServer(server, handler)) registration should coexist — the latter overrides the former.
Comparison table
| Quality | Tools | Custom Methods (current) | Custom Methods (proposed) |
|---|---|---|---|
| Server registration | mcp.AddTool(s, t, h) |
mcp.AddReceivingCustomMethod(s, name, h) |
import _ (auto) or Method.RegisterServer(s, h) |
| Client registration | None needed | mcp.AddSendingCustomMethod[P,R](c, name) |
None needed (auto via init()) |
| Calling | cs.CallTool(ctx, params) |
mcp.CallCustomMethod[P,R](ctx, cs, name, p) |
latin.Translate(ctx, cs, "hello") |
| Generics visible to consumer | No | Yes, repeated 3× | No |
| Method name strings | Once | Repeated 3× | Zero (captured in CustomMethod) |
Example restructure
The example should demonstrate the extension author vs extension consumer split:
examples/custom-method/
├── latinext/ # Extension author's package
│ └── latin.go # Types, Method, init(), Translate(), DefaultHandler
└── main.go # Extension consumer — import latinext, one-line setup, call
The consumer's main.go should look as close to the basic tool example as possible.
…y allocating fresh values before meta-injection
|
@guglielmo-san thanks for the merge, there are still a few ergonomic/syntactic things missing that would be nice IMO and as @jeongukjae mentioned, for the sake of completeness we are missing notifications and also client side extensions which allow for extensions similar to roots or sampling. Happy to do it as a follow up. |
Description
This PR introduces support for custom (non-standard) JSON-RPC methods in the MCP Go SDK.
This feature enables servers to handle arbitrary JSON-RPC methods and clients to invoke them, all while maintaining full type safety and integrating seamlessly with the SDK's existing middleware and session management.
Fixes #954